feat(presence): presence badges, auto-idle, and Discord-style status picker#689
feat(presence): presence badges, auto-idle, and Discord-style status picker#689Just-Insane wants to merge 80 commits intoSableClient:devfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds end-to-end presence UX (badges + status picker + auto-idle) and improves app visibility event plumbing, including sliding-sync presence bootstrapping behavior.
Changes:
- Add presence badges to the compact DM rail and account switcher avatar, plus a Discord-style status picker.
- Implement auto-idle with configurable timeout and introduce a presence REST bootstrap for sliding-sync environments.
- Refactor
appEventsvisibility handling and expanduseAppVisibilitysession-sync/heartbeat logic.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/utils/appEvents.ts | Replace single visibility handlers with multi-subscriber emit/on API. |
| src/app/state/settings.ts | Add presenceMode setting + ephemeral presenceAutoIdledAtom (also introduces enableMessageBookmarks). |
| src/app/pages/client/sidebar/DirectDMsList.tsx | Render presence badges on compact DM avatars using useUserPresence. |
| src/app/pages/client/sidebar/AccountSwitcherTab.tsx | Add own-presence badge + status picker UI that writes presenceMode. |
| src/app/pages/client/ClientNonUIFeatures.tsx | Drive actual presence broadcasting + auto-idle integration and SW visibility handling. |
| src/app/hooks/useUserPresence.ts | Add sliding-sync REST bootstrap + client-level fallback listener; update labels. |
| src/app/hooks/useUserPresence.test.tsx | New unit tests for useUserPresence. |
| src/app/hooks/usePresenceAutoIdle.ts | New hook implementing inactivity auto-idle + visibility/activity listeners. |
| src/app/hooks/usePresenceAutoIdle.test.tsx | New unit tests for auto-idle behavior and cleanup. |
| src/app/hooks/useClientConfig.ts | Add experiment/sessionSync config types + variant selection helper + presence timeout config. |
| src/app/hooks/useAppVisibility.ts | Rebuild visibility/focus handlers + optional SW session-sync heartbeat behavior. |
| src/app/features/settings/developer-tools/DevelopTools.tsx | Add “Rotate Encryption Sessions” developer tool action. |
| config.json | Add presenceAutoIdleTimeoutMs default (5 min). |
| .changeset/presence-sidebar-badges.md | Changeset entry for presence badges. |
| .changeset/presence-auto-idle.md | Changeset entry for auto-idle + status picker. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| removeListener: vi.fn(), | ||
| }; | ||
|
|
||
| vi.mock('$hooks/useMatrixClient', () => ({ |
There was a problem hiding this comment.
The test mocks '$hooks/useMatrixClient', but useUserPresence imports useMatrixClient via a relative path ('./useMatrixClient'). This mock won’t be applied, so the hook will use the real matrix client and these tests will fail or become integration tests unintentionally. Mock the correct module path (or change useUserPresence to import from the aliased path consistently).
| vi.mock('$hooks/useMatrixClient', () => ({ | |
| vi.mock('./useMatrixClient', () => ({ |
| mx.setPresence({ | ||
| presence: effectiveState, | ||
| status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', |
There was a problem hiding this comment.
mx.setPresence is always sending status_msg: '' when not in DND. That will clear any existing status message set by this app (Profile status editor) or other clients, potentially on every mount/state change. Consider omitting status_msg entirely unless you’re explicitly setting DND, and/or persisting/restoring the previous status message when toggling DND so user-defined status text isn’t lost.
| mx.setPresence({ | |
| presence: effectiveState, | |
| status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', | |
| // Only send status_msg when explicitly broadcasting DND; otherwise omit it so we | |
| // don't clear any existing user-defined status text from this or other clients. | |
| mx.setPresence({ | |
| presence: effectiveState, | |
| ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), |
| (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online | ||
| ); | ||
|
|
||
| let presenceBadge: ReactNode; | ||
| if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { |
There was a problem hiding this comment.
lastActiveTs is optional in UserPresence (REST fallback sets it to undefined when last_active_ago is absent), but this logic treats undefined !== 0 as “known”. That can cause presence badges to render even when we don’t actually have a timestamp/bootstrapped presence. Consider explicitly checking lastActiveTs != null && lastActiveTs !== 0, or gating on a dedicated “hasPresenceData” flag from the hook.
| (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online | |
| ); | |
| let presenceBadge: ReactNode; | |
| if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { | |
| (p) => | |
| p && | |
| p.lastActiveTs != null && | |
| p.lastActiveTs !== 0 && | |
| p.presence === Presence.Online | |
| ); | |
| let presenceBadge: ReactNode; | |
| if ( | |
| !isGroupDM && | |
| singleDMPresence && | |
| singleDMPresence.lastActiveTs != null && | |
| singleDMPresence.lastActiveTs !== 0 | |
| ) { |
| // experimental | ||
| enableMessageBookmarks: boolean; | ||
|
|
There was a problem hiding this comment.
enableMessageBookmarks is introduced here (interface + defaults) but isn’t mentioned in the PR description and appears unrelated to presence. Consider removing it from this PR or splitting it into a dedicated changeset/PR so the scope stays focused and reviewable.
| // (including bridge bots). fire-and-forget per room. | ||
| encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); |
There was a problem hiding this comment.
crypto.prepareToEncrypt(room) is invoked fire-and-forget for each room. If it returns a Promise (or can throw), this can lead to unhandled rejections and makes failures invisible. Consider explicitly voiding and attaching a .catch(...) per call, or awaiting with controlled concurrency if you need to ensure key sharing is actually queued successfully.
| // (including bridge bots). fire-and-forget per room. | |
| encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); | |
| // (including bridge bots). fire-and-forget per room, but surface failures. | |
| encryptedRooms.forEach((room) => { | |
| void Promise.resolve() | |
| .then(() => crypto.prepareToEncrypt(room)) | |
| .catch((error) => { | |
| console.error('Failed to prepare room encryption', room.roomId, error); | |
| }); | |
| }); |
| // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never | ||
| // delivered via sync. As a result, User.presence stays at the SDK default and | ||
| // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. | ||
| // Guard against empty userId — callers that render a fixed number of hooks (e.g. group DM | ||
| // slots) pass '' for absent members; firing getPresence('') would be a malformed request. | ||
| if (userId && (!user || user.getLastActiveTs() === 0)) { | ||
| mx.getPresence(userId) | ||
| .then((resp) => { | ||
| if (cancelled) return; | ||
| setPresence({ | ||
| presence: resp.presence as Presence, | ||
| status: resp.status_msg, | ||
| active: resp.currently_active ?? false, | ||
| lastActiveTs: | ||
| resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, | ||
| }); |
There was a problem hiding this comment.
The REST bootstrap (mx.getPresence(userId)) will run for every hook instance where getLastActiveTs() === 0 (e.g. MSC4186 sliding-sync), and this hook is used in large lists (members list, room nav, etc.). That can easily become an N+1 flood of /presence/{userId}/status requests. Consider adding a shared in-memory cache + in-flight dedupe (and ideally a short TTL) so multiple components/users don’t trigger redundant requests.
| .catch(() => { | ||
| // Presence not available on this server (404 or not supported) — keep existing state. | ||
| }); |
There was a problem hiding this comment.
The REST fallback swallows all errors. The PR description calls out ignoring 404 when presence isn’t enabled, but this also hides transient network/server errors (and makes debugging harder). Consider only suppressing known “not supported/not found” cases (e.g. MatrixError with httpStatus===404 / M_NOT_FOUND) and logging others at debug/warn level.
|
|
||
| useEffect(() => { | ||
| if (!mx) return; | ||
| if (!phase2VisibleHeartbeat) return undefined; |
There was a problem hiding this comment.
The heartbeat effect runs whenever phase2VisibleHeartbeat is enabled, even if mx is currently undefined (ClientRoot calls useAppVisibility(mx) during initial load). That will schedule recurring timers and repeatedly call pushSessionNow('heartbeat'), which will always ‘skipped’ and can generate noisy logs / unnecessary work. Consider guarding the heartbeat loop on mx being defined (and/or only starting once session prerequisites exist).
| if (!phase2VisibleHeartbeat) return undefined; | |
| if (!phase2VisibleHeartbeat || !mx) return undefined; |
| const [rotateState, rotateAllSessions] = useAsyncCallback< | ||
| { rotated: number; total: number }, | ||
| Error, | ||
| [] | ||
| >( | ||
| useCallback(async () => { | ||
| const crypto = mx.getCrypto(); | ||
| if (!crypto) throw new Error('Crypto module not available'); | ||
|
|
||
| const encryptedRooms = mx | ||
| .getRooms() | ||
| .filter( | ||
| (room) => | ||
| room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId) | ||
| ); | ||
|
|
||
| await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId))); | ||
| const rotated = encryptedRooms.length; | ||
|
|
||
| // Proactively start session creation + key sharing with all devices | ||
| // (including bridge bots). fire-and-forget per room. | ||
| encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); | ||
|
|
||
| return { rotated, total: encryptedRooms.length }; | ||
| }, [mx]) |
There was a problem hiding this comment.
This PR adds a new Developer Tools action to rotate Megolm sessions across all encrypted rooms, but this functionality isn’t mentioned in the PR description (which is focused on presence). Consider splitting this into a separate PR/changeset to keep scope aligned and reduce risk for the presence feature rollout.
| // Clear auto-idle so the badge updates immediately on manual selection. | ||
| setAutoIdled(false); | ||
| // Re-enable presence broadcasting if the master toggle was off | ||
| if (!sendPresence) setSendPresence(true); |
There was a problem hiding this comment.
Selecting a status updates settings but does not close the popout menu (setMenuAnchor(undefined)), unlike other menu actions (e.g. Add Account / Settings). If the intended UX is to close after selection, consider closing the menu in this handler for consistency.
| if (!sendPresence) setSendPresence(true); | |
| if (!sendPresence) setSendPresence(true); | |
| setMenuAnchor(undefined); |
Updated configuration for homeserver and push notifications.
Pass VITE_SENTRY_DSN, VITE_SENTRY_ENVIRONMENT, VITE_APP_VERSION, SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT to the build step so that the Docker image build (dev, integration, and release tags) includes Sentry instrumentation and source map uploads, matching the Cloudflare deploy workflow. Environment mapping: - dev branch / release tags → production - integration branch / manual dispatch without tag → preview
- Adds pre-push hook that runs typecheck, lint, and format checks - Blocks pushes that would fail CI - Includes install script for easy setup - Tracked on personal/config to persist across dev pulls
Linux/Codespaces-clean branch — removes macOS NVM lazy-loader, Homebrew paths, macOS-only OMZ plugins, and hardcoded macOS gitconfig paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a 'messageGroupingThreshold' setting (default: 2 minutes, matching upstream) that controls how long before a new sender block starts in the timeline. Exposed as a number input (1–60 min) in Messages settings.
- Add INJECTED_EXPERIMENT_FLAGS build constant (from VITE_FEATURE_* env vars) - Add experiments?: Record<string, boolean> to ClientConfig type - Add useExperimentFlag() and setExperimentOverride() to useClientConfig - ExperimentsPanel: per-flag toggles with localStorage override and Reset button - Settings-level dev tools: render ExperimentsPanel when developerTools is on - Room-level dev tools: Rotate Megolm Session tile for encrypted rooms
# Conflicts: # src/app/features/settings/general/General.tsx # src/app/state/settings.ts # vitest.config.ts
- debugLogger: wrap constructor localStorage.getItem in try/catch - settings: wrap getSettings/setSettings in try/catch - test/setup.ts: install in-memory localStorage polyfill before jsdom initialises so module-level singleton access resolves correctly All 60 test files (570 tests) now pass on Node.js 22.
RoomEvent.UnreadNotifications is emitted BY room.fixupNotifications(). The handleUnreadNotifications listener was calling getUnreadInfo with applyFixup: true, which calls room.fixupNotifications() again, producing an infinite loop: fixupNotifications → setUnreadNotificationCount → emit → handleUnreadNotifications → getUnreadInfo → fixupNotifications → … Fix: pass applyFixup: false inside the UnreadNotifications handler. The fixup has already run (it's what triggered the event); reading the updated counts directly avoids re-entering the fixup path.
- useBookmarks: reads org.matrix.msc4438.bookmark.<id> events + index - toggleBookmark: writes/removes individual events + updates index - BookmarksList: shared component for panel and inbox page - Add /inbox/bookmarks/ route with full-page bookmarks view
- getIndexIds reads bookmark_ids instead of bookmarks
- toggleBookmark writes bookmark_ids to index on add/remove
- toggleBookmark marks deleted events with { deleted: true } instead of {}
- readBookmarks skips events with deleted: true
… members drawer - AccountSwitcherTab: wrap SidebarAvatar in AvatarPresence with current user's dot - DirectDMsList: add AvatarPresence badge on 1:1 DM icons using the DM user's presence - MembersDrawer: replace lastActiveTs !== 0 guard with presence !== Offline so online users show a dot
Description
Combined presence PR — replaces #608 and #672 with a single branch rebased onto
upstream/devto resolve merge conflicts cleanly.Presence sidebar badges (from #608)
DirectDMsList) and the account switcher avatar (AccountSwitcherTab).AvatarPresenceandPresenceBadgecomponents for consistent badge rendering.m.presenceevents, souseUserPresencenow falls back toGET /_matrix/client/v3/presence/{userId}/statusto bootstrap presence state. If the server returns 404, the error is silently ignored.ClientEvent.Eventlistener fallback for when theUserobject doesn't exist yet (sliding sync race condition).Auto-idle & status picker (from #672)
presenceModesetting — persists the user's chosen status across sessions.presenceAutoIdleTimeoutMsclient config option to customize or disable (0) the auto-idle timer.userIdpresence fetch to prevent 400 errors during initial load race conditions.useUserPresencehook including the auto-idle timer and status transitions.DND mode
presence: onlinepresence: unavailablepresence: online,status_msg: dndpresence: offlineSupersedes #608 and #672.
Type of change
Checklist